axum on LambdaでHTTPレスポンスをストリーミング配信(Server-Sent Events)する

axum on LambdaでHTTPレスポンスをストリーミング配信(Server-Sent Events)する

Clock Icon2024.10.20

はじめに

axumはRust製のWebサーバーフレームワークです。最近「RustによるWebアプリケーション開発 設計からリリース・運用まで」を読み非常に使いやすいフレームワークだと思ったので、趣味のWeb(HTTP)サーバーはこちらを使おうと思いました。
https://amzn.asia/d/0NLa7wb

ただALBやECS構成だと、料金的に高いので、サーバーレスで運用しつつHTTPレスポンスストリーミングが利用できる構成を検討してみました。

構成

構成は先日のServerlessDays Tokyo 2024で「AWS Lambda Web Adapterが可能にした新しいサーバーレスの実装パターン」で紹介されていた構成が良いなと思いました。

https://speakerdeck.com/tmokmss/aws-lambda-web-adapterwohuo-yong-suruxin-siisabaresunoshi-zhuang-patan?slide=8

axum

具体的なメリットは資料にある通りですが、個人的には以下の点が気に入っています。

  • Lambda Web Adapterを利用するため、ローカルでそのまま動作する(=Lambdaに特化したコードが必要ないため可搬性がたかい)
  • Lambdaの起動時間(900秒)を生かした、長時間のストリーミング
  • 前段にCloudFrontを置くことでWAFでLambda Function URLsを保護できる

レスポンスストリーミングには、この構成ではTransfer-Encoding: chunkedServer-Sent Eventsがよくあり、後者はあまり記事がなかったので実際にできるか試してみました。

実装

今回のアプリ実装のリポジトリは以下の通りです。全体像がわかりにくい場合はこちらを参考にしてください。

https://github.com/shuntaka9576/axum-on-lambda-template

アプリケーション(axum)実装

今回は3つのエンドポイントを作成します。GETとPOSTでそれぞれSSEの検証します。なぜ両方のメソッドで検証するのかは後述します。

メソッド パス HTTPリクエストボディ 説明
GET /hello - シンプルなGETレスポンス
GET /sse - GETメソッドでSSE。数字をインクリメントするレスポンスを返却(1秒間隔,15回)
POST /sse {"count": 10} POSTメソッドSSE。数字をインクリメントするレスポンスを返却(1秒間隔,countで指定した数字回)

アプリケーションコードは以下の通りです。

https://github.com/shuntaka9576/axum-on-lambda-template/blob/486c32fab00ef3c936b29143f9c53d123a7fb00e/web-app/src/main.rs#L1-L113

インフラ実装

axum用のDockerfileの作成 + Lambda Web Adapterの導入

先ほどのaxumをホスティングするDockerfileを書きます。マルチステージビルドで、ビルドし、バイナリを配備するようにします。

https://github.com/shuntaka9576/axum-on-lambda-template/blob/486c32fab00ef3c936b29143f9c53d123a7fb00e/web-app/Dockerfile#L1-L15

この1行で、Lambda Web Adapterの導入は完了です。
https://github.com/shuntaka9576/axum-on-lambda-template/blob/486c32fab00ef3c936b29143f9c53d123a7fb00e/web-app/Dockerfile#L10

axum用のLambda

https://github.com/shuntaka9576/axum-on-lambda-template/blob/486c32fab00ef3c936b29143f9c53d123a7fb00e/iac/lib/api-stack.ts#L31-L41

シュッと使えることを目的にしているので、コンテナレジストリ(ECR)は意識しないようにfromImageAssetsを利用して、ローカルでビルドしたものをホスティングします。
https://github.com/shuntaka9576/axum-on-lambda-template/blob/486c32fab00ef3c936b29143f9c53d123a7fb00e/iac/lib/api-stack.ts#L32-L34

レスポンスストリームに対応するため、2箇所invoke modeの設定を忘れないようにしましょう。!記載の部分です。

https://github.com/shuntaka9576/axum-on-lambda-template/blob/486c32fab00ef3c936b29143f9c53d123a7fb00e/iac/lib/api-stack.ts#L31-L41
AWS_LWA_INVOKE_MODEはコンテナ側に書いても問題ないです。

https://github.com/shuntaka9576/axum-on-lambda-template/blob/486c32fab00ef3c936b29143f9c53d123a7fb00e/iac/lib/api-stack.ts#L43-L46

CloudFront + OAC + Lambda@Edge

CloudFrontとLambda@Edgeの実装は、こちらの記事と実装が非常に参考になります。

https://dev.classmethod.jp/articles/cloudfront-lambda-url-with-post-put-request/
https://github.com/joe-king-sh/lambda-function-urls-with-post-put-sample

今回は上記の記事同様に、Lambda@Edgeではbodyのハッシュ値だけ計算し、sigv4はOACで署名します。

https://github.com/shuntaka9576/axum-on-lambda-template/blob/486c32fab00ef3c936b29143f9c53d123a7fb00e/iac/lib/api-stack.ts#L43-L101

今回POSTも検証するのはこのLambda@Edgeの実装が正しく動作していることを検証するためです。

検証

スタックをデプロイすると以下のコマンドが出力されます。1つずつ実行します。

$ npx cdk deploy web-app-stack
Bundling asset lambda-edge-stack/LambdaEdgeFunction/Code/Stage...

(中略)

 ✅  web-app-stack

✨  Deployment time: 27.63s

Outputs:
web-app-stack.ApiCommands = CloudFront URL: https://CLOUD_FRONT_ENDPOINT

curl commands:
curl -v -H "Content-Type: application/json" -X GET https://CLOUD_FRONT_ENDPOINT/hello
curl -v -H "Content-Type: application/json" -X GET https://CLOUD_FRONT_ENDPOINT/sse
curl -v -H "Content-Type: application/json" -d '{"count": 10}' -X POST https://CLOUD_FRONT_ENDPOINT/sse
Stack ARN:
arn:aws:cloudformation:ap-northeast-1:622455551446:stack/web-app-stack/a21a2910-8dc2-11ef-b51b-0e569e4b2f3d

✨  Total time: 29.1s

GET /hello

$ curl -v -H "Content-Type: application/json" -X GET https://CLOUD_FRONT_ENDPOINT/hello
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying 65.9.37.144:443...
* Connected to CLOUD_FRONT_ENDPOINT (65.9.37.144) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/ssl/cert.pem
*  CApath: none
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-AES128-GCM-SHA256
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=*.cloudfront.net
*  start date: Jul 30 00:00:00 2024 GMT
*  expire date: Jul  3 23:59:59 2025 GMT
*  subjectAltName: host "CLOUD_FRONT_ENDPOINT" matched cert's "*.cloudfront.net"
*  issuer: C=US; O=Amazon; CN=Amazon RSA 2048 M01
*  SSL certificate verify ok.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://CLOUD_FRONT_ENDPOINT/hello
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: CLOUD_FRONT_ENDPOINT]
* [HTTP/2] [1] [:path: /hello]
* [HTTP/2] [1] [user-agent: curl/8.4.0]
* [HTTP/2] [1] [accept: */*]
* [HTTP/2] [1] [content-type: application/json]
> GET /hello HTTP/2
> Host: CLOUD_FRONT_ENDPOINT
> User-Agent: curl/8.4.0
> Accept: */*
> Content-Type: application/json
>
< HTTP/2 200
< content-type: text/plain; charset=utf-8
< content-length: 12
< date: Sat, 19 Oct 2024 02:45:24 GMT
< x-amzn-requestid: 4a5dce5a-7560-49e4-9f19-175d45b7d583
< x-amzn-remapped-content-length: 12
< x-amzn-trace-id: Root=1-67131d44-7ef610622128a07630afc1db;Parent=143aee524dbc825b;Sampled=0;Lineage=1:8320e3b3:0
< x-amzn-remapped-date: Sat, 19 Oct 2024 02:45:24 GMT
< x-cache: Hit from cloudfront
< via: 1.1 113c59bcc7514e6035b0efada4559c76.cloudfront.net (CloudFront)
< x-amz-cf-pop: NRT12-C5
< x-amz-cf-id: FA_oiqc8rv-_rw-VWOpsN3HBfV_aCDFrzVWGfHA2lG2Zfu8v49Fl7A==
< age: 78258
<
* Connection #0 to host CLOUD_FRONT_ENDPOINT left intact
Hello, axum!

GET /sse

$ curl -v -H "Content-Type: application/json" -X GET https://CLOUD_FRONT_ENDPOINT/sse
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying 65.9.37.144:443...
* Connected to CLOUD_FRONT_ENDPOINT (65.9.37.144) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/ssl/cert.pem
*  CApath: none
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-AES128-GCM-SHA256
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=*.cloudfront.net
*  start date: Jul 30 00:00:00 2024 GMT
*  expire date: Jul  3 23:59:59 2025 GMT
*  subjectAltName: host "CLOUD_FRONT_ENDPOINT" matched cert's "*.cloudfront.net"
*  issuer: C=US; O=Amazon; CN=Amazon RSA 2048 M01
*  SSL certificate verify ok.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://CLOUD_FRONT_ENDPOINT/sse
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: CLOUD_FRONT_ENDPOINT]
* [HTTP/2] [1] [:path: /sse]
* [HTTP/2] [1] [user-agent: curl/8.4.0]
* [HTTP/2] [1] [accept: */*]
* [HTTP/2] [1] [content-type: application/json]
> GET /sse HTTP/2
> Host: CLOUD_FRONT_ENDPOINT
> User-Agent: curl/8.4.0
> Accept: */*
> Content-Type: application/json
>

< HTTP/2 200
< content-type: text/event-stream
< date: Sun, 20 Oct 2024 00:30:22 GMT
< x-amzn-requestid: 64efb055-2c41-48ee-bad6-4434eed1d849
< cache-control: no-cache
< x-amzn-trace-id: Root=1-67144f1e-428762b77f4a9ddd65dcf8e7;Parent=61976a1d9f114323;Sampled=0;Lineage=1:8320e3b3:0
< x-amzn-remapped-date: Sun, 20 Oct 2024 00:30:22 GMT
< x-cache: Miss from cloudfront
< via: 1.1 f46e301bb0f5ba5ccb0896790f796b42.cloudfront.net (CloudFront)
< x-amz-cf-pop: NRT12-C5
< x-amz-cf-id: q6uB1mNo2yZeXoE1aC38kCFtiJn9XRAOX5YrqBMLvtZMhC7xtazW3A==
<
data: 0

data: 1

data: 2

data: 3

data: 4

data: 5

data: 6

data: 7

data: 8

data: 9

data: 10

data: 11

data: 12

data: 13

data: 14

data: [DONE]

* Connection #0 to host CLOUD_FRONT_ENDPOINT left intact

POST /sse

$ curl -v -H "Content-Type: application/json" -d '{"count": 10}' -X POST https://CLOUD_FRONT_ENDPOINT/sse

Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying 65.9.37.88:443...
* Connected to CLOUD_FRONT_ENDPOINT (65.9.37.88) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/ssl/cert.pem
*  CApath: none
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-AES128-GCM-SHA256
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=*.cloudfront.net
*  start date: Jul 30 00:00:00 2024 GMT
*  expire date: Jul  3 23:59:59 2025 GMT
*  subjectAltName: host "CLOUD_FRONT_ENDPOINT" matched cert's "*.cloudfront.net"
*  issuer: C=US; O=Amazon; CN=Amazon RSA 2048 M01
*  SSL certificate verify ok.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://CLOUD_FRONT_ENDPOINT/sse
* [HTTP/2] [1] [:method: POST]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: CLOUD_FRONT_ENDPOINT]
* [HTTP/2] [1] [:path: /sse]
* [HTTP/2] [1] [user-agent: curl/8.4.0]
* [HTTP/2] [1] [accept: */*]
* [HTTP/2] [1] [content-type: application/json]
* [HTTP/2] [1] [content-length: 13]
> POST /sse HTTP/2
> Host: CLOUD_FRONT_ENDPOINT
> User-Agent: curl/8.4.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 13
>
< HTTP/2 200
< content-type: text/event-stream
< date: Sun, 20 Oct 2024 00:31:12 GMT
< x-amzn-requestid: 1b49bf41-26a2-44da-abf9-22e54348e0cb
< cache-control: no-cache
< x-amzn-trace-id: Root=1-67144f50-134aa8517f485d875f6792c5;Parent=243cb83d67eebac8;Sampled=0;Lineage=1:8320e3b3:0
< x-amzn-remapped-date: Sun, 20 Oct 2024 00:31:12 GMT
< x-cache: Miss from cloudfront
< via: 1.1 1f83e59f609910f3106a87395db1ee4a.cloudfront.net (CloudFront)
< x-amz-cf-pop: NRT12-C5
< x-amz-cf-id: nSSTekpgusXkOdF6EmgStnD9G4ZmT1msShOPnJlxjQstPcbeYSUenw==
<
data: 0

data: 1

data: 2

data: 3

data: 4

data: 5

data: 6

data: 7

data: 8

data: 9

data: [DONE]

* Connection #0 to host CLOUD_FRONT_ENDPOINT left intact

起動通りの結果が得られました。

さいごに

Lambda Web Adapterは手軽に導入できて便利でした。Honoやexpress等を使って環境変数で起動の判定をすることは可能ですが、両方の起動方法を保守しなくて良くなるため、心理的にコストが下がることを実感しました。

パフォーマンス面はこれから運用してみて、また記事を書こうと思います。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.